1"use client";
2
3import { useState } from "react";
4import { useParams, useRouter } from "next/navigation";
5import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
6import { apiFetch, pageApi } from "@/lib/api";
7import { useAuth } from "@/hooks/use-auth";
8import { useWorkspace } from "@/hooks/use-workspace";
9import { toast } from "@/stores/ui-store";
10import { Field, Input } from "@/components/ui/form";
11import { PERM_LEVELS, type ShareLevel } from "@/lib/constants";
12import type { WorkspaceRole } from "@/lib/types";
13
14const LEVEL_OPTIONS: { value: ShareLevel; label: string; description: string }[] = [
15 { value: "view", label: "Can view", description: "Read-only access to this page." },
16 { value: "edit", label: "Can edit", description: "Can edit this page and its sub-pages." },
17 { value: "public", label: "Anyone with the link", description: "No sign-in required." }
18];
19
20
24export default function PageShareRoute() {
25 const params = useParams<{ id: string; workspace: string }>();
26 const pageId = params?.id ?? "";
27 const slugOrId = params?.workspace ?? "";
28 const router = useRouter();
29 const qc = useQueryClient();
30 const { user } = useAuth();
31 const wsQuery = useWorkspace(slugOrId);
32
33
34
35
36 const effectivePolicy = useQuery({
37 queryKey: ["workspaces", wsQuery.data?.id, "policy", "effective"],
38 queryFn: async () => {
39 const res = await apiFetch<{ policy: { disableGuestPublicShare?: boolean } }>(
40 `/workspaces/${wsQuery.data?.id}/policy/effective`
41 );
42 return res.policy;
43 },
44 enabled: Boolean(wsQuery.data?.id),
45 retry: false
46 });
47 const guestPublicBlocked = effectivePolicy.data?.disableGuestPublicShare === true;
48
49
50
51const [level, setLevel] = useState<"view" | "edit" | "public">("view");
52 const [email, setEmail] = useState("");
53 const [role, setRole] = useState<WorkspaceRole>("member");
54 const [copied, setCopied] = useState(false);
55
56 const share = useQuery({
57 queryKey: ["pages", pageId, "share"],
58 queryFn: async () => {
59 try {
60 return await pageApi.share(pageId, { level: "view" });
61 } catch {
62
63 return null;
64 }
65 },
66 enabled: Boolean(pageId),
67 retry: false
68 });
69
70 const saveShare = useMutation({
71 mutationFn: (input: { level: "view" | "edit" | "public" }) =>
72 pageApi.share(pageId, input),
73 onSuccess: (data) => {
74 qc.setQueryData(["pages", pageId, "share"], data);
75 }
76 });
77
78 const invite = useMutation({
79 mutationFn: (input: { email: string; role: WorkspaceRole }) =>
80 apiFetch<{ ok: true }>(`/pages/${pageId}/invites`, { method: "POST", body: input })
81 });
82
83 const shareUrl = share.data?.url ?? (pageId ? `${typeof window !== "undefined" ? window.location.origin : ""}/f/${pageId}` : "");
84
85 const copyLink = async () => {
86 try {
87 await navigator.clipboard.writeText(shareUrl);
88 setCopied(true);
89 setTimeout(() => setCopied(false), 2000);
90 } catch {
91 toast("error", "Copy failed", "Select the link text and copy manually.");
92 }
93 };
94
95 return (
96 <div className="mx-auto max-w-lg px-6 py-10">
97 <button
98 type="button"
99 onClick={() => router.back()}
100 className="mb-6 text-sm text-muted-foreground hover:text-foreground"
101 >
102 ← Back to page
103 </button>
104 <h1 className="text-xl font-semibold">Share</h1>
105 <p className="mt-1 text-sm text-muted-foreground">Control who can access this page.</p>
106
107 <div className="mt-6 space-y-6">
108 <section className="space-y-3">
109 <h2 className="text-sm font-medium">Public link</h2>
110 <div className="flex gap-2">
111 <Input readOnly value={shareUrl} className="flex-1 text-muted-foreground" aria-label="Share link" />
112 <button
113 type="button"
114 onClick={copyLink}
115 className="shrink-0 rounded-md border border-border bg-background px-3 py-1.5 text-sm font-medium hover:bg-accent"
116 >
117 {copied ? "Copied" : "Copy link"}
118 </button>
119 </div>
120 {guestPublicBlocked && (
121 <p
122 data-testid="guest-public-share-notice"
123 className="rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-xs text-destructive"
124 >
125 Your organization has disabled public (guest) sharing. “Anyone with the link” is not available —
126 the server blocks it.
127 </p>
128 )}
129 <div className="space-y-2" role="radiogroup" aria-label="Link permissions">
130 {LEVEL_OPTIONS.map((opt) => {
131
132
133
134
135 const value = opt.value as string;
136 const blocked = guestPublicBlocked && value === "public";
137 return (
138 <label
139 key={value}
140 data-testid={value === "public" ? "share-level-public" : undefined}
141 className={
142 "flex items-start gap-3 rounded-md border border-border p-3 has-[:checked]:border-ring has-[:checked]:ring-1 has-[:checked]:ring-ring " +
143 (blocked ? "opacity-50" : "cursor-pointer")
144 }
145 >
146 <input
147 type="radio"
148 name="share-level"
149 value={value}
150 checked={level === (opt.value as string)}
151 disabled={blocked}
152 onChange={() => setLevel(opt.value as "view" | "edit" | "public")}
153 className="mt-1"
154 />
155 <span>
156 <span className="block text-sm font-medium">
157 {opt.label}
158 {blocked && <span className="ml-1.5 text-xs text-destructive">(disabled by your org)</span>}
159 </span>
160 <span className="block text-xs text-muted-foreground">{opt.description}</span>
161 </span>
162 </label>
163 );
164 })}
165 </div>
166 <button
167 type="button"
168 disabled={saveShare.isPending}
169 onClick={() => void saveShare.mutate({ level })}
170 className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:opacity-90 disabled:opacity-50"
171 >
172 {saveShare.isPending ? "Saving…" : "Save link settings"}
173 </button>
174 </section>
175
176 <section className="space-y-3">
177 <h2 className="text-sm font-medium">Invite people</h2>
178 <Field label="Email" htmlFor="invite-email" hint={`You are invited as ${user?.email ?? "a member"}.`}>
179 <Input
180 id="invite-email"
181 type="email"
182 placeholder="teammate@company.com"
183 value={email}
184 onChange={(e) => setEmail(e.target.value)}
185 />
186 </Field>
187 <div className="flex gap-2">
188 <select
189 value={role}
190 onChange={(e) => setRole(e.target.value as WorkspaceRole)}
191 aria-label="Invite role"
192 className="h-9 rounded-md border border-input bg-background px-2 text-sm"
193 >
194 <option value="viewer">Can view</option>
195 <option value="commenter">Can comment</option>
196 <option value="member">Can edit</option>
197 </select>
198 <button
199 type="button"
200 disabled={!email.trim() || invite.isPending}
201 onClick={() =>
202 invite.mutate({ email: email.trim(), role }, {
203 onSuccess: () => {
204 toast("success", "Invitation sent", email.trim());
205 setEmail("");
206 }
207 })
208 }
209 className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:opacity-90 disabled:opacity-50"
210 >
211 {invite.isPending ? "Sending…" : "Send invite"}
212 </button>
213 </div>
214 </section>
215
216 <p className="text-xs text-muted-foreground">
217 {PERM_LEVELS.length} permission levels are enforced server-side — the frontend only
218 renders what your role allows (never the source of truth).
219 </p>
220 </div>
221 </div>
222 );
223}
224